#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2008 Mikael Schönenberg
#
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
"""%prog [options] <command> [addons]

Addons with lowercase names will be fetched from curse.com
Addons where the first letter is uppercase will be fetched from wowinterface.com

Commands:
  install - install/update addons
  list - list managed addons
  remove - remove managed addons
  save - download and save addons
  upgrade - upgrade existing addons"""

import cStringIO
import optparse
import os
import pickle
import re
import sys
import threading
import time
import urllib
import zipfile

def cached(func, _cache={}):
    """
    Decorator that caches the return value from a function.

    Usage:
    @cached
    def add(x, y):
        return x + y

    For every combination of x, y, add will only be invoked once.
    """
    _cache[func] = {}
    def cached_func(*args):
        try:
            result = _cache[func][args]
        except KeyError:
            result = _cache[func][args] = func(*args)
        return result

    return cached_func

class Addon(object):

    def __init__(self, addonId, settings=None):
        self.id = addonId
        self.data = None
        self.settings = {} if settings is None else settings

        self._downloadVersion = None

    def __getattr__(self, attr):
        if attr == 'localVersion':
            return self.settings.get(attr)
        raise AttributeError('%s as no attribute %r' % (self, attr))

    def __setattr__(self, attr, value):
        if attr == 'localVersion':
            self.settings[attr] = value
        super(Addon, self).__setattr__(attr, value)

    @property
    def downloadVersion(self):
        return self._downloadVersion # xxx

    def _getDownloadVesion(self):
        return self._downloadVersion

    def fetch(self):
        return urllib.urlopen(self._getDownloadURL())

    def _remove(self, files):
        failures = 0
        files = list(files)
        removed = set()
        while files:
            name = files.pop()
            path = os.path.join(self.installdir, name)
            if os.path.exists(path):
                if os.path.isdir(path):
                    remove = os.rmdir
                else:
                    remove = os.unlink
                try:
                    remove(path)
                    failures = 0
                    removed.add(name)
                except OSError:
                    files.insert(0, name)
                    failures += 1
            assert failures < (len(files) or 1)
        return removed

    def _extract(self, zipob, targetDir):
        extracted = set()
        for name in zipob.namelist():
            parts = name.split('/')

            # Create a directory for all elements in path but the last
            # one.  Note, that if `name' actually points to a
            # directory (ends with /), the last element in the list
            # will be an empty string.
            for index in xrange(len(parts) - 1):
                current = parts[:index + 1] # Up to and including current part
                path = os.path.join(targetDir, *current)
                if not os.path.exists(path):
                    os.mkdir(path)
                currentName = '/'.join(current) + '/'
                extracted.add(currentName)

            if not name.endswith('/'):
                path = os.path.join(targetDir, name)
                file(path, 'wb').write(zipob.read(name))
                extracted.add(name)

        return sorted(extracted)

    def install(self):
        data = self.fetch().read()
        zipob = zipfile.ZipFile(cStringIO.StringIO(data))
        self._remove(self.settings.get('files', []))

        extracted = self._extract(zipob, self.installdir)
        self.settings['files'] = extracted
        self.localVersion = self.downloadVersion
        return self.localVersion

    def remove(self):
        installed = self.settings.get('files', [])
        removed = self._remove(installed)
        assert set(installed) == set(removed)
        self.settings['files'] = []

    # xxx
    def _getFileName(self, url):
        return url.geturl().rsplit('/')[-1]

    def save(self, targetDir):
        url = self.fetch()
        fileName = self._getFileName(url)
        file(os.path.join(targetDir, fileName), 'wb').write(url.read())

    def upgrade(self):
        if not self.localVersion or self.localVersion != self.downloadVersion:
            return self.install()

class CurseAddon(Addon):

    curse = 'http://wow.curse.com'
    baseurl = curse + '/downloads/wow-addons/details/'

    @cached
    def _getAddonPage(self):
        return urllib.urlopen(self.baseurl + self.id + '.aspx').read()

    @cached
    def _getDownloadPageURL(self):
        pattern = '(/downloads/wow-addons/details/%s/download/\\d+.aspx)'

        page = self._getAddonPage()
        match = re.search(pattern % self.id, page)
        assert len(match.groups()) == 1
        return self.curse + match.group(1)

    @cached
    def _getDownloadPage(self):
        return urllib.urlopen(self._getDownloadPageURL()).read()

    @property
    def downloadVersion(self):
        if not self._downloadVersion:
            pattern = (r'<div class="header">\s*<h1>.*</h1>'
                       r'\s*<h2>(.*)</h2>\s*</div>')

            page = self._getDownloadPage()
            match = re.search(pattern, page)
            assert len(match.groups()) == 1
            self._downloadVersion = match.group(1)
        return self._downloadVersion

    @cached
    def _getDownloadURL(self):
        pattern = '(/downloads/download.aspx\?(?:app=projectFiles&amp;)?pi=\\d+&amp;fi=\\d+)'

        page = self._getDownloadPage()
        match = re.search(pattern, page)
        assert len(match.groups()) == 1
        return self.curse + match.group(1).replace('&amp;', '&')

class WoWInterfaceAddon(Addon):

    wowinterface = 'http://www.wowinterface.com'
    search = wowinterface + '/downloads/search.php'

    def _getFileName(self, url):
        pattern = r'attachment; filename="(.*)"'

        header = url.headers.getheader('Content-Disposition')
        match = re.search(pattern, header)
        assert len(match.groups()) == 1
        return match.group(1)

    @cached
    def _getAddonPage(self):
        return urllib.urlopen(self.search + '?search=' +
                              urllib.quote_plus(self.id)).read()

    @property
    def downloadVersion(self):
        if not self._downloadVersion:
            pattern = (r'<td class="alt2">'
                       r'<div class="infoboxfont1">Version:</div>'
                       r'</td>\s*'
                       r'<td class="alt2">'
                       r'<div class="smallfont">(.*)</div>'
                       r'</td>')

            page = self._getAddonPage()
            match = re.search(pattern, page)
            assert len(match.groups()) == 1
            self._downloadVersion = match.group(1)
        return self._downloadVersion

    @cached
    def _getDownloadURL(self):
        pattern = (r'<a href="(/downloads/.*)">\s*'
                   r'<img.*alt="Download Latest Version\.".*>\s*'
                   r'</a>')

        page = self._getAddonPage()
        match = re.search(pattern, page)
        assert len(match.groups()) == 1
        return self.wowinterface + match.group(1).replace('&amp;', '&')

class Settings(dict):

    path = os.path.join(os.path.expanduser('~/.kopsimo'))

    def __getitem__(self, item):
        if item == 'addons' and item not in self:
            self[item] = {}
        return super(Settings, self).__getitem__(item)

    def load(self):
        self.clear()
        if os.path.exists(self.path):
            self.update(pickle.load(file(self.path)))

    def save(self):
        pickle.dump(dict(self), file(self.path, 'wb'))

class AutoFlushingStream(object):

    def __init__(self, stream):
        self.stream = stream

    def write(self, data):
        self.stream.write(data)
        self.stream.flush()

    def __getattr__(self, attr):
        return getattr(self.stream, attr)

class UserInterface(object):

    command = None
    installdir = None

    def __init__(self):
        self.addons = {}
        self.parser = optparse.OptionParser(__doc__)
        self.settings = Settings()

        self.stdin = sys.stdin
        self.stdout = AutoFlushingStream(sys.stdout)

    def exit(self):
        self.parser.print_help()
        raise SystemExit

    def _addonFactory(self, id):
        if id not in self.addons:
            settings = self.settings['addons'].setdefault(id, {})
            if id[0].isupper():
                addon = self.addons[id] = WoWInterfaceAddon(id, settings)
            else:
                addon = self.addons[id] = CurseAddon(id, settings)
            addon.installdir = self.installdir
        return self.addons[id]

    def _readSpecFile(self, filename):
        stream = self.stdin if filename == '-' else file(filename, 'r')
        for line in stream:
            uncommented = line.split('#')[0].strip()
            if uncommented:
                yield uncommented

    def cmd_install(self):
        for addon in self.addons.itervalues():
            print 'Installing %s...' % addon.id,
            newVersion = addon.install()
            print 'done.'

    def cmd_list(self):
        names = {}
        for id in self.settings['addons']:
            names[id] = '"%s"' % id if ' ' in id else id
        format = '%%-%ss  # %%s' % max(len(name) for name in names.itervalues())
        for id, name in sorted(names.items()):
            version = self._addonFactory(id).localVersion
            print >> self.stdout, format % (name, version)

    def cmd_remove(self):
        while self.addons:
            id, addon = self.addons.popitem()
            print >> self.stdout, 'Removing', id
            addon.remove()
            del self.settings['addons'][id]

    def cmd_save(self):
        for addon in self.addons.itervalues():
            addon.save(os.getcwd())

    def cmd_upgrade(self):
        map(self._addonFactory, self.settings['addons'])
        print >> self.stdout, 'Upgrading addons. Left: %d' % len(self.addons),
        threads = []
        for name, addon in sorted(self.addons.items()):
            thread = threading.Thread(target=addon.upgrade, name=addon.id)
            threads.append((thread, addon, addon.localVersion))
            thread.start()

        updated = []

        while threads:
            thread, addon, previousLocalVersion = threads.pop(0)
            if thread.isAlive():
                threads.append((thread, addon, previousLocalVersion))
            else:
                format = '%d'
                if addon.localVersion != previousLocalVersion:
                    format = '%d!'
                    updated.append(addon)
                print >> self.stdout, format % len(threads),
            time.sleep(.01)
        print >> self.stdout

        for addon in sorted(updated, key=lambda addon: addon.id):
            print >> self.stdout, addon.id, 'updated to', addon.localVersion

    def execCommand(self):
        command = getattr(self, 'cmd_' + self.command, self.exit)
        command()
        self.settings.save()

    def parseCommandLine(self):
        self.parser.add_option('-f', '--file', dest='filename',
                               metavar='{FILE|-}',
                               help="file of curse addon ids, or - for stdin")
        self.parser.add_option('-i', '--install-dir', dest='installdir',
                               metavar='INSTALL_DIR',
                               help='World of Warcraft AddOn directory')
        if not sys.argv[1:]:
            return self.exit()

        options, args = self.parser.parse_args(sys.argv[1:])

        if args[0] not in ('install', 'list', 'remove', 'save', 'upgrade'):
            return self.exit()

        self.command = args.pop(0)

        self.settings.load()
        self.installdir = options.installdir or self.settings.get('installdir')
        if not self.installdir:
            self.exit()

        if options.filename:
            addons = self._readSpecFile(options.filename)
        else:
            addons = args
        map(self._addonFactory, addons)

        self.settings['installdir'] = self.installdir

def main():
    ui = UserInterface()
    ui.parseCommandLine()
    ui.execCommand()
    return ui

if __name__ == '__main__':
    main()

